Le carnet On est
Annale 2024-2025 · Session Normale · ING2-GIA

Examen Réseau — Session Normale

2 heures, 4 exercices : questions de cours, pipes père/fils (transformation majuscules), trains A↔B avec sémaphores et threads, gestion des signaux SIGINT/SIGQUIT, et socket TCP client/serveur multi-clients.

2h · ING2-GIA 4 exercices · 20 points 1 feuille recto-verso autorisée

i. Le sujet

Le PDF ne s'affiche pas. Télécharger le sujet.

↗ Sujet en plein écran

ii. Notions mobilisées

ExoSujetNotion cléChapitre
1Questions de coursexec, processus vs threads, zombies, signaux, SIGINTCours 1 · Cours 2 · Cours 4
2.1Tubes père↔filspipe, fork, read/write, closeCours 3
2.2Trains A↔B (voie unique)Sémaphores POSIX, threads, exclusion mutuelle directionnelleCours 2 · Cours 4
3SIGINT confirm, SIGQUIT relaisigaction, kill, gestion des signauxCours 2
4Socket TCP client/serveursocket, bind, listen, accept, connect, fork pour multi-clientsCours 5

1. Exercice 1 — Questions de cours (5 pts)

Q1 — Rôle de la fonction exec() en C

La famille exec* (execl, execv, execlp, execvp…) remplace l'image du processus courant par celle d'un nouveau programme. Le PID est conservé, mais le code, les données et la pile sont écrasés. Les descripteurs de fichiers ouverts sont préservés (sauf flag FD_CLOEXEC).

Combiné à fork, c'est le mécanisme standard pour lancer un programme depuis un autre : le père fork, le fils exec, et le père wait.

Q2 — Processus vs threads : 2 avantages, 2 inconvénients
Avantages des processus
  • Isolation mémoire : chaque processus a son propre espace d'adressage. Un crash n'affecte pas les autres.
  • Sécurité : pas de partage accidentel de données, communication explicite via IPC.
Inconvénients
  • Création coûteuse : fork recopie la table des pages, plus lent que pthread_create.
  • Communication lente : IPC (pipes, signaux, mémoire partagée…) demande des appels système, contrairement à la mémoire partagée directe entre threads.
Q3 — Processus zombie et gestion par l'OS

Un processus zombie est un processus terminé dont le parent n'a pas encore lu le code de sortie via wait() ou waitpid(). Il occupe une entrée dans la table des processus uniquement pour conserver son statut (PID, code de retour).

Gestion :

  • Tant que le père n'appelle pas wait, le zombie persiste.
  • Quand le père meurt sans avoir récupéré ses zombies, ces derniers sont adoptés par init (PID 1) qui les nettoie immédiatement.
  • Pour éviter les zombies : appeler wait, ou installer un gestionnaire pour SIGCHLD qui appelle waitpid, ou positionner signal(SIGCHLD, SIG_IGN) pour que le système réolisse automatiquement.
Q4 — Rôle principal des signaux

Les signaux sont un mécanisme de notification asynchrone entre processus (ou du noyau vers un processus). Ils permettent de :

  • Notifier un événement : interruption (SIGINT), fin d'un fils (SIGCHLD), division par zéro (SIGFPE), accès mémoire invalide (SIGSEGV).
  • Contrôler l'exécution : arrêt (SIGSTOP), reprise (SIGCONT), terminaison (SIGTERM, SIGKILL).
  • Communication minimale entre processus : signaux user-defined (SIGUSR1, SIGUSR2).

Ils sont délivrés de façon asynchrone : le programme est interrompu, le handler s'exécute, puis le programme reprend où il en était.

Q5 — Comportement par défaut de SIGINT

SIGINT (signal n°2) est généré par le noyau quand l'utilisateur tape Ctrl+C dans le terminal. Le comportement par défaut est la terminaison immédiate du processus au premier plan, sans nettoyage spécifique (les destructeurs C++ par exemple ne sont pas appelés).

On peut intercepter SIGINT avec signal() ou sigaction() pour exécuter un nettoyage avant de sortir — ou pour demander confirmation comme dans l'exo 3.

2. Exercice 2 — Partie 1 : Pipes père/fils, transformation majuscules

Le père envoie une chaîne au fils via pipe 1. Le fils la met en majuscules et la renvoie au père via pipe 2. Le père affiche.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <ctype.h>

int main() {
   int pere_vers_fils[2];     // pipe 1
   int fils_vers_pere[2];     // pipe 2
   char message[] = "bonjour le monde";
   char buf[256];

   pipe(pere_vers_fils);
   pipe(fils_vers_pere);

   if (fork() == 0) {                // ────── FILS ──────
      close(pere_vers_fils[1]);       // fils ne PA→F que en lecture
      close(fils_vers_pere[0]);       // fils ne F→PA que en écriture

      int n = read(pere_vers_fils[0], buf, 256);
      buf[n] = '\0';
      for (int i = 0; i < n; i++)
         buf[i] = toupper(buf[i]);

      write(fils_vers_pere[1], buf, n);
      close(pere_vers_fils[0]);
      close(fils_vers_pere[1]);
      exit(0);
   } else {                          // ────── PÈRE ──────
      close(pere_vers_fils[0]);       // père écrit seulement vers fils
      close(fils_vers_pere[1]);       // père lit seulement depuis fils

      write(pere_vers_fils[1], message, strlen(message));
      close(pere_vers_fils[1]);     // signaler EOF au fils

      int n = read(fils_vers_pere[0], buf, 256);
      buf[n] = '\0';
      printf("%s\n", buf);            // "BONJOUR LE MONDE"

      close(fils_vers_pere[0]);
      wait(NULL);
   }
   return 0;
}
⚠ Pourquoi deux pipes ? Un seul pipe serait bidirectionnel en théorie, mais half-duplex : si le père écrit puis lit sans précaution, il risque de relire sa propre chaîne avant que le fils ne l'ait consommée. Deux pipes garantissent un sens par tube → comportement déterministe (cf. Cours 3 §3).

3. Exercice 2 — Partie 2 : Trains A↔B avec sémaphores

Deux villes A et B, une voie unique. Les trains peuvent circuler dans le même sens en parallèle, mais jamais dans deux sens opposés simultanément. C'est le problème du pont à voie unique ou des lecteurs/rédacteurs avec deux groupes.

Idée — protocole « lecteur unique du sens »
  • Un compteur pour chaque sens (nb_AB, nb_BA).
  • Un mutex pour protéger les compteurs.
  • Un sémaphore voie pour réserver l'accès au tronçon.
  • Le premier train d'un sens prend le sémaphore voie ; le dernier le libère.
Ainsi tant qu'au moins un train roule dans un sens, l'autre sens est bloqué.
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>

sem_t voie;                // 1 si libre, 0 si occupée
pthread_mutex_t mtx_AB = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mtx_BA = PTHREAD_MUTEX_INITIALIZER;
int nb_AB = 0, nb_BA = 0;

void* train_AB(void* arg) {
   pthread_mutex_lock(&mtx_AB);
   nb_AB++;
   if (nb_AB == 1) sem_wait(&voie);    // premier de A→B : réserve la voie
   pthread_mutex_unlock(&mtx_AB);

   printf("Train A→B circule\n");
   sleep(2);                                   // traversée
   printf("Train A→B sort par B\n");

   pthread_mutex_lock(&mtx_AB);
   nb_AB--;
   if (nb_AB == 0) sem_post(&voie);    // dernier de A→B : libère
   pthread_mutex_unlock(&mtx_AB);
   return NULL;
}

void* train_BA(void* arg) {
   pthread_mutex_lock(&mtx_BA);
   nb_BA++;
   if (nb_BA == 1) sem_wait(&voie);
   pthread_mutex_unlock(&mtx_BA);

   printf("Train B→A circule\n");
   sleep(2);
   printf("Train B→A sort par A\n");

   pthread_mutex_lock(&mtx_BA);
   nb_BA--;
   if (nb_BA == 0) sem_post(&voie);
   pthread_mutex_unlock(&mtx_BA);
   return NULL;
}

int main() {
   sem_init(&voie, 0, 1);                       // 1 = voie libre

   pthread_t t1, t2, t3;
   pthread_create(&t1, NULL, train_AB, NULL);
   pthread_create(&t2, NULL, train_AB, NULL);   // 2 trains AB en // OK
   pthread_create(&t3, NULL, train_BA, NULL);   // devra attendre

   pthread_join(t1, NULL);
   pthread_join(t2, NULL);
   pthread_join(t3, NULL);
   sem_destroy(&voie);
   return 0;
}

Compilation : gcc trains.c -lpthread -o trains.

🔑 Risque de famine
Si des trains A→B arrivent en continu, les trains B→A peuvent attendre indéfiniment. Solution : ajouter un mécanisme d'équité (sémaphore tourniquet, ou faire passer les trains par lots). Le sujet ne le demande pas, mais c'est un bon point à mentionner.

4. Exercice 3 — Signaux SIGINT et SIGQUIT

Q1 — Interrompre calculcomplexe avec Ctrl+C

On installe un gestionnaire pour SIGINT qui demande confirmation à l'utilisateur.

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>

int calculcomplexe(int i);                  // existe ailleurs

void handler_sigint(int sig) {
   char rep;
   printf("\nVoulez-vous arrêter ? (o/n) : ");
   fflush(stdout);
   scanf(" %c", &rep);
   if (rep == 'o' || rep == 'O') {
      printf("Arrêt définitif.\n");
      exit(0);
   }
   printf("Calcul poursuivi.\n");
}

int main(void) {
   int tab[100];
   struct sigaction sa;
   sa.sa_handler = handler_sigint;
   sigemptyset(&sa.sa_mask);
   sa.sa_flags = SA_RESTART;                // redémarre read/scanf si interrompu
   sigaction(SIGINT, &sa, NULL);

   for (int i = 0; i < 100; i++) {
      tab[i] = calculcomplexe(i);
   }
   return EXIT_SUCCESS;
}

Q2 — Programme ping.c

Sur réception de SIGQUIT (Ctrl+\), afficher « ping ! » et relayer le signal à un autre PID. Le programme affiche « ping ! » indéfiniment.

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

pid_t cible;                                  // PID donné en argument

void handler_quit(int sig) {
   printf("ping !\n");
   kill(cible, SIGQUIT);                     // relaie le signal
}

int main(int argc, char** argv) {
   if (argc != 2) {
      fprintf(stderr, "Usage : %s <pid_cible>\n", argv[0]);
      exit(1);
   }
   cible = atoi(argv[1]);

   struct sigaction sa;
   sa.sa_handler = handler_quit;
   sigemptyset(&sa.sa_mask);
   sa.sa_flags = 0;
   sigaction(SIGQUIT, &sa, NULL);

   while (1) {
      printf("ping !\n");
      sleep(1);
   }
   return 0;
}
Pourquoi sigaction plutôt que signal ? signal() a un comportement variable selon les Unix (parfois le handler est réinitialisé après chaque réception). sigaction() est portable, plus précis (masque, drapeaux SA_RESTART, SA_SIGINFO) et toujours préféré dans le code de production.

5. Exercice 4 — Socket TCP client/serveur multi-clients

Q1 — Rôles de inet_aton et getsockname

inet_aton Convertit une adresse IPv4 au format texte (« 192.168.1.10 ») en sa représentation binaire dans une structure in_addr.
int inet_aton(const char*, struct in_addr*);
Renvoie 1 si OK, 0 si erreur de format.
getsockname Récupère l'adresse locale associée à une socket (utile quand bind a été fait avec port=0 ou IP automatique).
int getsockname(int sock, struct sockaddr*, socklen_t*);
Renseigne l'IP et le port effectivement utilisés côté local.

Q2 — Compléter le code client TCP

Les zones à compléter dans le sujet, dans l'ordre :

// création de la socket
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
   fprintf(stderr, "%s: socket %s\n", argv[0], strerror(errno));
   exit(1);
}

serveur.sin_family = AF_INET;
serveur.sin_port   = htons(sport);
inet_aton(argv[2], &serveur.sin_addr);

// connexion au serveur
if (connect(sock, (struct sockaddr*)&serveur, sizeof(serveur)) < 0) {
   fprintf(stderr, "%s: connect %s\n", argv[0], strerror(errno));
   exit(1);
}

len = sizeof(client);
getsockname(sock, (struct sockaddr*)&client, &len);

while (1) {
   char buf_read[256], buf_write[256];
   printf("donner le message à envoyer: ");
   scanf("%s", buf_write);
   printf("le message à envoyer par le client : %s\n", buf_write);

   ret = write(sock, buf_write, strlen(buf_write));    // ← première zone
   if (ret <= strlen(buf_write)) { ... }

   ret = read(sock, buf_read, sizeof(buf_read));      // ← deuxième zone
   if (ret <= 0) { ... }
   printf("le message reçu : %s\n", buf_read);
}
close(sock);

Q3 — Code du serveur multi-clients

Pour gérer plusieurs clients, on fork après chaque accept : le père retourne accepter le prochain client, le fils dialogue avec le client courant.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>

int main(int argc, char** argv) {
   int ecoute, dialogue;
   struct sockaddr_in serveur, client;
   socklen_t len = sizeof(client);

   if (argc != 2) {
      fprintf(stderr, "Usage : %s port\n", argv[0]);
      exit(1);
   }

   // nettoyer les zombies automatiquement
   signal(SIGCHLD, SIG_IGN);

   ecoute = socket(AF_INET, SOCK_STREAM, 0);
   serveur.sin_family      = AF_INET;
   serveur.sin_port        = htons(atoi(argv[1]));
   serveur.sin_addr.s_addr = INADDR_ANY;

   if (bind(ecoute, (struct sockaddr*)&serveur, sizeof(serveur)) < 0) {
      perror("bind");
      exit(1);
   }
   listen(ecoute, 5);

   while (1) {
      dialogue = accept(ecoute, (struct sockaddr*)&client, &len);
      if (dialogue < 0) continue;

      if (fork() == 0) {              // ─── enfant : dialogue dédié ───
         close(ecoute);              // l'enfant n'a pas besoin du socket écoute
         char buf[256];
         int n;
         while ((n = read(dialogue, buf, 255)) > 0) {
            buf[n] = '\0';
            printf("reçu de %s : %s\n", inet_ntoa(client.sin_addr), buf);
            // écho en majuscules par exemple
            for (int i = 0; i < n; i++) buf[i] = toupper(buf[i]);
            write(dialogue, buf, n);
         }
         close(dialogue);
         exit(0);
      } else {                       // ─── parent : retourne accepter ───
         close(dialogue);            // le parent n'utilise pas ce socket
      }
   }
   return 0;
}
Pourquoi fermer ecoute dans l'enfant et dialogue dans le parent ? Les descripteurs sont hérités par fork : les deux processus ont les deux sockets ouverts. Fermer celui qu'on n'utilise pas libère la ressource côté noyau et permet la propagation de fin de connexion. Sinon le client ne détecte jamais l'EOF.

Alternatives à fork : pthread_create pour un dialogue par thread (plus léger, mais nécessite mutex pour les données partagées), ou select/epoll pour un serveur mono-thread multi-clients (plus complexe mais le plus performant).

Notions ré-activées

  • exec remplace l'image du processus, conserve le PID
  • Zombie = fils terminé sans wait ; signal(SIGCHLD, SIG_IGN) évite
  • SIGINT = Ctrl+C, terminaison par défaut ; sigaction pour intercepter
  • SIGQUIT = Ctrl+\, fin avec core dump par défaut
  • Pipes père↔fils = deux pipes pour bidirectionnel propre
  • Voie unique trains = 1 sémaphore + 1 mutex par sens + compteurs
  • inet_aton : texte → binaire IPv4
  • getsockname : récupère l'adresse locale d'une socket
  • Serveur multi-clients = boucle accept + fork ; père ferme dialogue, fils ferme écoute
  • connect/accept : connect côté client, accept côté serveur
  • htons sur le port (host to network short)